The Croquet Virtual DOM Framework Last Modified: 2021-09-15
Introduction
The framework uses Croquet for the network communication part and the UI part is based on the document object model of the web browser.
The Software Architecture of Croquet
Applications written using Croquet have a clear separation of "model" and "view" and should be written with this in mind. As in common usage, the model refers to the logic part of the application. However, in the case of Croquet, it is more strictly expected to satisfy the constraint that "when a model is in a state S and receives a message M, the state S' after processing the message will be the same state that can be reliably reproduced from S and M, no matter what computer the model is running on."
In order to satisfy this restriction there are some innovations, such as patching Math.random()
, so that developers can write, in a natural way, "message handlers" that specify the processing for each message of a given type.
The view mediates between such a model and the user. It displays the state of the model, and when a user performs any input action the view sends a message to the equivalent model for all users in the session, so that the model will change to the same new state for all users.
The model is expected to be in the same state, but the views may be different for each client, because of, for example, differences in screen size, or because an operation that a user is doing may be visible for that user, before being sent to the model. The code describing the behavior of the view is allowed to read out the state of the model. Using an analogy with network programming in the server-client model, the view's code is the client program, but it is like being able to read out the state of the server directly from that program. This also contributes to the descriptiveness of Croquet.
In other words, we follow the principle that "common information among clients should be stored in the model and other information should be stored in the view".
Virtual DOM
Although it is not impossible to build an application with the basic Croquet mechanisms described above, constructing a view using the Document Object Model (DOM) provided by the browser involves a laborious process of repeatedly writing trivial pieces of code. In a nutshell, given that the information held by the DOM elements used to display on the screen is also, in most cases, what needs to be shared among participating clients, we can reduce coding effort by keeping the DOM information used by the view in the model as virtual DOM elements, so that the handlers for user operations can be written just once in the model, and the system can automatically generate and update the actual DOM elements.
The Croquet Virtual DOM Framework is implemented based on this perspective.
How Behaviors are Described
Applications are defined not simply as state, but also in terms of the "behaviors" that change that state. In Croquet's Virtual DOM Framework, the basic idea is to treat code as data, with users able to change applications' behaviors with code alterations at runtime. This shared authoring environment can also be disabled if no code changes are needed. In any case, the code can also be treated as data, which contributes to the simplicity of implementation.
As shown in the example below, in this framework behaviors are written in accordance with certain conventions. Although the grammar includes the "class" keyword, these are actually class extensions, inspired by the language features known as traits and expanders. Simply put, they are collections of methods that do not change the virtual DOM element itself but act as external parts, adding behaviors that can be invoked using a special call()
syntax.
Concepts, Terminology
Model
As described above, a model is an object that is used for defining the logic part of an application. It causes state changes by receiving external messages. The word model may refer to the entire logic of the application, but when described as Model
, it may also refer to individual instances of classes that inherit from the Croquet.Model
class. In other words, it is normal for the logic part of an application to be made up of multiple Model
classes, and to use multiple instances of those classes.
View
The view displays the state of the model on the screen, and also accepts input from the user and sends messages to the model. As with Model
, when referring to View
this can mean multiple instances of classes that each inherit from Croquet.View
.
Client
Basically, a browser-based Croquet application. The client participates in a certain Croquet session. The clients participating in that session all share a model with identical state.
Reflector
A server on the network that is responsible for delivering messages from a view to all the clients participating in the session.
Virtual DOM Element
In the Croquet Virtual DOM Framework, instances of the Element
class are created in the model and mimic the interface of a real DOM. For the part of the application that must be the same across multiple clients, consistency is maintained using updates to the properties of these elements.
Virtual DOM View
When an instance of Element
is displayed on the screen, a view object called ElementView
is created. ElementView
holds a reference to an actual DOM element, and when its corresponding Element
changes, the ElementView
is responsible for making the DOM element reflect the change. Also, when an event fires in the actual DOM element, the ElementView
sends it to the model as a Croquet message.
Actual DOM Element
What a virtual DOM view holds onto, and what the browser actually displays on the screen.
Virtual DOM Element Reference
A reference (pointer) to a Virtual DOM Element is internally represented by a data type called ElementRef
. ElementRef introduces a simple indirection to accommodate possible slippage between the view and the model.
For example, imagine that a group of users are using a graphical collaborative application. A user sees an element on screen and tries to move it, but by the time the request arrives at the model it is possible that that element has already been deleted by another user. The information sent from the view must allow the model code to look up the intended object, if it indeed still exists. An ElementRef serves this need.
expander
Behavior defined as a set of methods that can be "installed" in Element
or ElementView
. These are JavaScript classes written according to specific conventions.
Example 1 (Counter)
Let's start with a simple example.
First of all, I would like to refer to examples/counter.js
and counter.html
in the app/
directory. As mentioned above, code is also data, but at the moment, it is more efficient to use a normal code editor when writing code. However, as you can see in counter.js
, you need to write your code in accordance with certain conventions.
Invocation
First, copy the Croquet library file into a directory called `croquet.
# mkdir -p croquet; curl -L -o croquet/croquet-latest.min.js https://cdn.jsdelivr.net/npm/@croquet/croquet@1
The -L
option specifies to follow redirection. You may also copy the file from https://cdn.jsdelivr.net/npm/@croquet/croquet@1/pub/croquet.min.js
, or replace the <script>
tag to point to the jsdelivr URL.
Run server.js
in the top directory from a terminal using node
.
# node server.js &
If you don't have node.js installed but have Python 2 or Python 3, you can use the equivalent server written in Python.
# python server.py &
Both server programs use port 8000 by default so you can access an example at this location:
http://localhost:8000/apps/counter.html
If the fourth argument of makeMain()
is a string, a random session name prefixed by ?q=
is attached to the URL. You can enter the same session from another browser by accessing this extended URL.
File Organization
Here we refer to a file like counter.js
as a library file. A library is configured as follows:
<expander definition>...
<function definition>...
<class definition>...
<export statement with "expanders", "functions" and "classes" properties>
In counter.js
there is only one class definition: Counter
.
class Counter {
init() {
this.addEventListener("click", "reset");
if (this._get("count") === undefined) {
this._set("count", 0);
this.future(1000).call("Counter", "next");
}
console.log("Counter.init");
}
next() {
let c = this._get("count") + 1;
this._set("count", c);
this.value = "" + c;
this.future(1000).call("Counter", "next");
}
reset() {
let c = 0;
this._set("count", c);
this.value = "" + c;
}
}
function beCounter(parent, json) {
let text = parent.createElement("TextElement");
text.style.setProperty("width", "200px");
text.style.setProperty("height", "50px");
text.setDefault("serif", 12);
parent.appendChild(text);
text.setCode(parent.getLibrary("counter.Counter"));
}
export const counter = {
expanders: [Counter],
functions: [beCounter]
};
I'll explain it line by line below.
class Counter {
This defines an expander named Counter
. The keyword "class" is used to start a definition so that the syntax checker can handle the description. In other words, the syntax to write an expander is exactly JavaScript. Code written in a library file is processed as string data, so it is not allowed to refer to "free variables" from outside the expander. However, if you need access to other expanders' code, use the initialization-function library
argument as described below.
init() {
init()
is the name of a special method which is executed automatically when the expander is "installed" on an element. (However, the expander may be edited in the authoring tool, and in that case, init()
is called on the existing object each time the expander is edited).
this.addEventListener("click", "reset");
This adds an event handler for when a DOM click event occurs on the virtual DOM element where this expander is installed. Internally, a click handler is added to the actual DOM element, and when the user clicks, it is delivered to this expander. "reset"
is the name of the method in the expander to be invoked.
You can explicitly specify the expander as well as the method name, in the following manner:
this.addEventListener("click", "Counter.reset");
However, it is very rare to invoke a method from a different expander from where addEventListener
is called, so the common case is just to specify the method name.
Note that the this
variable in the expander method refers to the virtual DOM element where it is installed, not to the expander itself.
if (this._get("count") === undefined) {
this._set("count", 0);
}
As described above, init()
may be called multiple times for already existing elements. Therefore, when you initialize a variable in init()
and you want to keep the value even if the code changes a little while the same element survives, you should initialize it only when the value is not yet set. Checking against undefined
here implements that. On the other hand, calling addEventListener
for a virtual DOM element with the same arguments multiple times does not register multiple handlers. Here, the property count
is defined for the virtual DOM element.
_get()
and _set()
are methods to read and write the properties of a virtual DOM element. A virtual DOM element can hold only JSONizable values. Were they made readable and writable using simple dot notation, that would invite inadvertently storing inappropriate values, such as functions that have free variables. Instead, one must use the verbose _get()
and _set()
notation, chosen so as not to clash with internal property names, and must keep in mind that only JSONizable values can be stored.
this.future(1000).call("Counter", "next");
future()
is like setTimeout()
, with an argument specifying milliseconds of logical time. Based on the heartbeat sent by the reflector, all participating clients invoke the specified method after the specified time has elapsed. Normally, this logical time is synchronized with real time, so here the elapsed real time will also be about 1000 wall-clock milliseconds.
In this case, the method is call()
, meaning that the next
method of the expander installed with the name Counter
will be invoked. In the near future it is planned to make it possible to write just this.future(1000).next()
, implicitly specifying that the method is on the same expander, as with other calls to expander methods.
next() {
let c = this._get("count") + 1;
this._set("count", c);
this.value = "" + c;
this.future(1000).call("Counter", "next");
}
As shown above, next()
is executed 1000 milliseconds after the first call to init()
. After updating the count
property with _get()
and _set()
, a stringified version is written to the special property this.value
which is compatible with a DOM text element. (The fact that this
is a text element is specified by beCounter()
, shown below).
The last line of next
calls next()
itself again. Like requestAnimationFrame()
or setTimeout, this is a standard tactic for performing processing at a regular interval.
reset() {
let c = 0;
this._set("count", c);
this.value = "" + c;
}
This is the method called when a click event occurs, as specified by init()
. The count
property is set to 0, and the value
of the text
element is set to "0".
function beCounter(parent, json, persistentData) {
This is the function to create the virtual DOM element used by the application. As an analogy, you can think of it as embodying the exploratory work of interactively creating elements and setting styles and properties in an interface builder.
parent
is a virtual DOM element which is to be the parent of this "application". You can ignore json
for the time being.
let text = parent.createElement("TextElement");
Virtual DOM elements are created using the createElement()
of a virtual DOM element, instead of document.createElement()
. The basic design policy is to use div
elements for almost anything, so that as far as possible "everything is made of the same kind of object". However, since elements that provide some special features cannot be replaced by div
, there are text elements, video elements, iframe elements, and canvas elements, which can be created by specifying a type in the argument.
In this example, we are creating a virtual text element to display the string (though in this case there would be no problem with using a div
, since this text does not have to be collaboratively editable).
text.style.setProperty("width", "200px");
text.style.setProperty("height", "50px");
A virtual DOM element has basic properties such as style
and classList
, which can be used to set the CSS style. How to set CSS classes will be explained later.
parent.appendChild(text);
Use appendChild()
to add elements created in this way to the DOM tree.
text.setCode("counter.Counter");
Then, we use the method setCode
to set the behavior of the element, specifying the path to the expander called "Counter" within the "counter" library registered in the library created in the HTML file.
You may call setCode
with the actual code string of the expander, as opposed to the library path:
text.setCode(parent.getLibrary("counter.Counter"));
This form would be the one to use in cases where the expander may be modified at runtime.
export const counter = {
expanders: [Counter],
functions: [beCounter]
};
A library file like counter.js
exports objects with properties expanders
and functions
. The above parent.getLibrary("counter.Counter")
, or setCode("counter.Counter")
means to get the Counter
expander from the library exported as counter
.
The counter.html
that calls it looks like this:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<script type="text/javascript" charset="UTF-8" src="../croquet/croquet-latest.min.js"></script>
<script type="module">
import {makeMain, Library} from "../src/croquet-virtual-dom.js";
import {counter} from "../examples/counter.js";
let library = new Library();
library.addLibrary("counter", counter);
window.onload = makeMain("counter.beCounter", {
autoSleep: false,
appId: "io.croquet.vdom.counter",
apiKey: "<your api key from croquet.io/keys>",
password: "dummy",
tps: 1
}, library, "counter", null, true);
</script>
</head>
<body style="touch-action: none">
<div id="croquet-root" style="width: 100%; height: 100%"></div>
</body>
</html>
Picking out some details:
<script type="text/javascript" charset="UTF-8" src="croquet/croquet-latest.min.js"></script>
This loads the Croquet library copied into the croquet
directory. Alternatively, you can load a stable version of Croquet from https://cdn.jsdelivr.net/npm/@croquet/croquet@1
.
import {makeMain, Library} from "./src/framework.js";
import {counter} from "./examples/counter.js";
let library = new Library();
library.addLibrary("counter", counter);
Importing makeMain
from main.js
and Library
from library.js
is the same for any application as long as it follows this pattern. Import your own library, counter.js
, and register it to an instance of Library
.
The following line:
window.onload = makeMain("counter.beCounter", {autoSleep: false, appId: "io.croquet.vdom.counter", tps: 1}, library, "counter");
creates the function to be called as the onload
handler, specifying the function registered as counter.beCounter
as the entry point, with the autoSleep
session option (described in the Croquet Documentation), and the library
object that was created above. The last argument specifies the name of the session if it is a string, or if falsy will cause the page access to automatically create a new shared session and add a distinguishing session ID to the URL.
Development and Debugging of a New Application
We are deliberately avoiding complicated JavaScript bundling mechanisms, transpilation and such machinery. Instead, we pursue a development style in which you can just edit a file and reload the page to try it out immediately. To write a new application, you can create a new file like counter.js
above, and also make your own copy of counter.html
to call it.
isLocal
flag
Much of application development is about making visual adjustments and other model behavior checks, with no need to test out the sharing aspects, so working with just one client is enough. To simplify such a development cycle, if the query parameter ?isLocal
is specified in the URL, the application will be executed with the reflector's role replaced by a local emulation. In the case of counter.html
:
http://localhost:8000/counter.html?isLocal
will allow the application's code to be run exactly as is, but without the need for a network.
debugger
At present, there is a problem in that code written as an expander goes through a string representation before being evaluated and executed, which means that a browser debugger cannot locate the original source location. The good news is that by inserting a debugger
statement the debugger can be started, or if output is generated using console.log
then clicking on the reported line numbers in the output allows reference to the code in a "VM..." pseudo file. In the future we would like to improve the situation, such as by allowing expander code to be inserted without going through a string representation whenever possible.
Example 2 (Drawing)
As an example of an application with view code, here is a simple drawing program drawing.js
and drawing.html
.
class DrawModel {
init() {
this._set("color", "black");
this.subscribe(this.id, "line", "line");
this.subscribe(this.sessionId, "color", "color");
this.subscribe(this.sessionId, "clear", "clear");
this.subscribe(this.id, "pointerUp", "savePersistentData");
}
This model has color
and lines
(described below) as state properties, and calls subscribe()
to communicate with the view. The first and second arguments of subscribe()
are the scope and the message name, which together constitute a key agreed among the objects interested in these messages. The third argument of subscribe()
defines the method to be invoked when a message arrives.
In the case of "line" messages, we want only the view object that knows about this particular model to send "line" messages. This is achieved by using the model's this.id
as the scope; in effect, this declares that "only this model/view pair is interested in this message". The "color" message, by contrast, comes from a distinct virtual DOM element, and is to be received by all objects used in the session. Therefore as scope we use this.sessionId
, a shared value that all the session's objects have access to.
Croquet has a persistent-data mechanism to support code evolution. In essence, the developer has freedom to define a high-level application state description. When a new version of code is used, it can reconstruct the essential state from that data. In this drawing example, lines
is deemed essential while color
is not, and only the former is stored in the persistent data. Also, whenever a pointerup message is sent from any view, the model saves the persistent data.
line(data) {
if (!this._get("lines")) {
this._set("lines", []);
}
The "line" message is sent from the view to the model when the user makes a pointer movement on the screen to draw a line. A case to watch out for in Croquet is when a new client joins after other people have already performed some actions, such as - in this application - drawing some lines. This requires that the model hold information about past line segments, so here it is stored in a property called "lines". The property could be created in init()
, but let's try creating it when the information for the first line segment arrives.
let line = {...data, color: this._get("color")};
this._get("lines").push(line);
The object {command: "line", from: {x,y}, to: {x,y}}
is sent as data
. We augment it with color
, and add the result to the "lines" property.
this.publish(this.id, "drawLine", line);
}
And now we send this object in a drawLine
message, this time from the model to the view. It is important to remember that only JSONizable objects can be sent as messages.
color(color) {
this._set("color", color);
}
When a "color" message is received, its value is stored in a property ready for the next "line" message.
The savePersistentData()
calls the Croquet API to create the persistent data, and loadPersistentData()
fills in the lines
property from the data. Persistent data could be arbitrarily complex, but it is good practice to keep it to a minimum.
class DrawView {
init() {
this.addEventListener("pointerdown", "pointerDown");
DrawView
is an expander installed in the view that corresponds to the above DrawModel
. A view expander can also register event handlers for DOM events using addEventListener
.
As of 2021, almost all common browsers support DOM PointerEvents. We strongly recommend that an application exclusively use pointer events, and not handle mouse events or touch events.
this.subscribe(this.model.id, "drawLine", "drawLine");
In the last line of DrawModel.line()
, a message was sent using this.publish(this.id, "drawLine", line);
with the model's id
as the scope and "drawLine" as the name.
The model sends a cleared
message when it receives a clear
message and resets the lines
state. The view responds to the message and clears the canvas content.
this.subscribe(this.model.id, "cleared", "clear");
An ElementView
stores the corresponding Element
object itself as this.model
, and can read values (including id
) from the model freely. To receive "drawLine" messages, the DrawView
's subscribe
specifies the same scope and the same name. This is how the sending and receiving of the "drawLine" message for a particular DrawModel
/DrawView
pair is set up.
this.initDraw();
}
This calls the method initDraw()
, which is defined by the same expander.
initDraw() {
if (!this.model._get("lines")) {return;}
this.model._get("lines").forEach(data => {
this.drawLine(data);
});
}
The role of initDraw()
is to make sure that the screen of a participant joining a session that was already running shows correctly any lines that have already been drawn.
Since reading values from model objects is allowed, the virtual DOM view accesses the "lines" property from the object stored as this.model
. If a value exists for the "lines" property, each line is drawn using drawLine()
as if the participant had been in the session since the start.
drawLine()
is an expander method, but remember that a method within the same expander can be called using this.
. The call this.drawLine(data)
is equivalent to this.call("DrawView", "drawLine", data)
, because the call itself appears in a DrawView
expander method.
pointerDown(evt) {
This is the method registered in init()
as an event handler.
if (evt.buttons !== 1) {return;}
this.setPointerCapture(evt.pointerId);
We want to track pointermove events only between the pointer button being pressed and being released, and during that time we need to handle them even if the pointer leaves the DOM element's display area, and to handle a pointerup even if it occurs outside the area. Therefore, pointermove and pointerup handlers are set up afresh on pointerdown and released on pointerup. The ElementView
class has setPointerCapture()
method that utilizes the browser's pointer capture feature.
The pointerMove
method handles the drawing action.
if (this.lastPoint) {
let p = {x: evt.offsetX, y: evt.offsetY};
this.publish(this.model.id, "line", {command: "line", from: this.lastPoint, to: p});
this.lastPoint = p;
}
In this pointerMove
, if the view finds a property lastPoint
it publishes a message "line" so as to draw a line from lastPoint
to the current pointermove event's (offsetX, offsetY)
. The scope is this.model.id
, so as usual it will only be delivered to objects that specify the same scope. Finally, lastPoint
is updated to the new pointer position.
In the case of views, you can store arbitrary objects in properties, using the standard this.propertyname
syntax. However, accidentally using a property name that is also used by the system will cause problems, so we may take some action on this in the future.
pointerUp(_evt) {
this.removeEventListener("pointermove", "pointerMove");
this.removeEventListener("pointerup", "pointerUp");
this.lastPoint = null;
this.releaseAllPointerCapture();
this.publish(this.model.id, "pointerUp");
}
In pointerUp
, the handlers temporarily added are removed and lastPoint
is also cleared. The releaseAllPointerCapture()
is a convenience method to invoke the browser's releasePointerCapture()
to clear captured pointers.
let offsetX = evt.offsetX;
let offsetY = evt.offsetY;
this.lastPoint = {x: offsetX, y: offsetY};
this.addEventListener("pointermove", this.pointerMove);
this.addEventListener("pointerup", this.pointerup);
Still in pointerDown
, we set lastPoint
to the place where the pointerdown happened. In addition, we register two functions as transient event handlers until the pointerup occurs.
What should be noted here is the actual type of events being sent. The events delivered through the virtual DOM system are "cooked", with objects containing only somewhat abstract values in a way that absorbs client differences as much as possible. You can actually register event handlers directly with the real DOM elements, and, by doing so, you can handle actual "uncooked" DOM events. Either the program should use values chosen specifically to avoid inconsistencies, such as always using offsetX
, or call this.cookEvent()
to convert an event to a virtual event.
drawLine(data) {
let ctx = this.dom.getContext("2d");
ctx.lineWidth = 2;
ctx.strokeStyle = data.color;
ctx.beginPath();
ctx.moveTo(data.from.x, data.from.y);
ctx.lineTo(data.to.x, data.to.y);
ctx.stroke();
}
Using the data in a "drawLine" message sent from the model, draw on the actual DOM element stored in this.dom
.
class Color {
init() {
this.addEventListener("click", "color");
this.style.setProperty("background-color", "black");
}
This file defines another expander, called Color
. It is installed in a certain model.
In init()
, the method color
of the expander is specified to be called when a click event occurs, and the virtual-DOM style property background-color
is set to black
.
randomColor() {
let h = Math.floor(Math.random() * 256);
let s = "100%";
let l = "80%";
return `hsl(${h}, ${s}, ${l})`;
}
randomColor()
is used to get a random color from a hue ring. Because this Math.random()
is executed as model code, it is guaranteed that however many times clients call it, they will all be obtaining exactly the same sequence of values.
color() {
let color = this.randomColor();
this.style.setProperty("background-color", color);
this.publish(this.sessionId, "color", color);
}
The color
method is executed when this virtual DOM element is clicked. this.randomColor()
calls the randomColor()
method defined by the same expander, and the background-color
that was set to black
in init()
is replaced with the new color. The new color is then also published in a "color" message, using this.sessionId
as the scope.
sessionId
is shared across the whole application, so any object in the application can subscribe to messages that have this scope. As shown above, in this example all DrawModel
s subscribe to these "color" messages, and respond by changing their "color" property.
function beDrawing(parent, json, persistentData) {
let top = parent.createElement();
let canvas = parent.createElement("CanvasElement");
canvas.setCode("drawing.DrawModel");
canvas.setViewCode("drawing.DrawView");
let color = parent.createElement();
color.setCode("drawing.Color");
beDrawing
, like the earlier beCounter
, can be thought of as code that simplifies the process of assembling the DOM tree interactively.
The canvas
variable is assigned a CanvasElement
value, and color
a div
element. The CanvasElement
's model code is set to DrawModel
code obtained from library
, and its view code to the library's DrawView
. Because color
is a common behavior across all clients, it is only given model code.
color.style.setProperty("width", "60px");
color.style.setProperty("height", "60px");
color.style.setProperty("border-radius", "50%");
The styles of individual elements can also be set in this way.
color.domId = "color";
color.setStyleClasses(`
#color:hover {
border: 2px dotted white;
}
#color {
border: 2px solid white;
}`);
In addition, you can use a special method called setStyleClasses
to pass as a string a CSS block with CSS selectors. The property domId
, renamed because the property id
is already reserved by Croquet, sets the DOM id
property. The CSS block specifies the style for that id, with and without the :hover
pseudo-class.
top.style.setProperty("display", "flex");
top.style.setProperty("width", "550px");
top.appendChild(canvas);
top.appendChild(color);
parent.appendChild(top);
if (persistentData) {
canvas.call("DrawModel", "loadPersistentData", persistentData);
}
return parent;
These elements are made into a tree structure with appendChild()
and added to parent
.
export const drawing = {
expanders: [DrawModel, DrawView, Color, ClearButton],
functions: [beDrawing]
};
Everything is registered in the library drawing
. beDrawing()
will be called through makeMain
in drawing.html.
Example 3 (Video Chat)
In apps/video-chat.html
and examples/video-chat.js
you can find an example of video chat using SkyWay, a service provided by NTT.
The file skyway/skyway-latest.js
is a SkyWay library.
To run this example, get an API key from SkyWay and create a file called skyway/key.js
using the actual key you obtained.
export const key = 'abcd0123-abcd-aaaa-0000-012312012abc';
In the video chat example, it's important to note that almost nothing shown on the screen is being shared through Croquet. The connection status, layout of the individual videos, etc. are also different for each client. That's why examples/video-chat.js
only defines a view expander which, when notification of a new participant arrives from SkyWay, creates a new actual video element and sets its srcObject
property.
Another thing to note is that you cannot do JavaScript asynchronous processing in the model, because it would cause the timing of model execution to differ between participating clients. In this example, the SkyWay library and key.js
are loaded with dynamic import, and even if such values are to be used purely by model code, it is essential that they be loaded by view-side code, and then sent to the model using messages.
Example 4 (Text Chat)
In apps/text-chat.html
and examples/text-chat.js
you can find an example of a simple text chat. A notable feature introduced in the example is a way to specify the CSS class strings to control the appearance of the application. The initialization function beChat
calls the setStyleClasses()
method on chat
element. The selectors matches with the domId
property of the virtual DOM element that will become the id
property of the actual DOM element, and the CSS class names added by classList.add()
.
The setStyleClasses()
method specifies a CSS string that persists as long as the element stays in the DOM scene tree. For a certain kind of applications, such CSS strings can be attached to different elements instead of having one large string for the top-level element. In this particular case, however, where many similar elements with the same class will be added and removed from the DOM scene tree, it is more concise to just have a single description at the top level.